Skip to content

Allow Claude to write#7294

Open
gatesn wants to merge 2 commits intodevelopfrom
ngates/claude-write
Open

Allow Claude to write#7294
gatesn wants to merge 2 commits intodevelopfrom
ngates/claude-write

Conversation

@gatesn
Copy link
Copy Markdown
Contributor

@gatesn gatesn commented Apr 6, 2026

  • Split Claude automation into two workflows with separate trust boundaries: claude-write.yml for write-capable issue automation and follow-up edits on Claude-owned PRs, and claude-review.yml for read-only PR review.
  • Sets up a GitHub app (vortex-claude) so PRs pushed by Claude can run CI
  • Only claude-write can access the GitHub App private key, and only through the claude-automation environment. No other workflow gets that key.
  • claude-write uses a short-lived GitHub App installation token, not GITHUB_TOKEN, so PRs opened by Claude trigger normal pull_request CI.
  • claude-write runs only for trusted collaborators with write/maintain/admin access.
  • claude-write is issue-driven by default. It may also run from a PR conversation comment, but only when that PR is a same-repo PR originally opened by the Vortex Claude GitHub App (CLAUDE_APP_LOGIN).
  • claude-write refuses fork PR content entirely. There is no promotion path for forks.
  • claude-review has no GitHub App private key and no write-capable repo token. It uses the built-in GITHUB_TOKEN only for reading repo state and posting review comments.
  • claude-review can review same-repo PRs, but it must not create commits, push branches, open PRs, or update PR branches.
  • PR conversation comments on Claude-owned PRs are routed to claude-write; claude-review explicitly stands down on that path to avoid duplicate runs.
  • Both workflows reject bot-triggered invocations and gate access before running Claude logic.
  • Both workflows use persist-credentials: false on checkout so git operations do not silently fall back to the built-in GITHUB_TOKEN.
  • Fork PRs are always refused for Claude automation because fork content is treated as untrusted prompt input.

gatesn added 2 commits April 6, 2026 09:41
Signed-off-by: Nicholas Gates <nick@nickgates.com>
Signed-off-by: Nicholas Gates <nick@nickgates.com>
@gatesn gatesn added the changelog/chore A trivial change label Apr 6, 2026
@connortsui20
Copy link
Copy Markdown
Contributor

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 6, 2026

Claude finished @connortsui20's task in 2m 6s —— View job


PR Review

Todo

  • Gather context from PR description
  • Read claude-write.yml
  • Read claude-review.yml
  • Read (deleted) claude.yml for comparison
  • Provide comprehensive review feedback

Overall

The security design here is solid. Splitting into two workflows with separate trust boundaries, using a GitHub App token for write operations, gating on collaborator permission level, refusing fork content entirely, and pinning action SHAs rather than using mutable tags are all good practices. The .github/ modification check as an extra guard is a nice defensive layer.

A few issues worth addressing:


Bug: refuse-fork comment renders with literal \n instead of newlines

claude-review.yml line 183:

].join("\\n"),

Inside a YAML literal block scalar (|), \\n is passed to JavaScript as the two-character string \n (backslash + n). So .join("\\n") joins the array elements with the literal text \n, not actual newline characters. GitHub renders the comment body as Markdown, and \n text is not a line break — the message will render as one long jumbled line.

Fix: use "\n" (single backslash in YAML literal block = actual newline in JS):

].join("\n"),

Fix this →


Minor: Loose equality in JS

claude-review.yml line 108:

if (actorHasWrite != 'true') {

Both operands are strings, so != and !== behave identically here, but !== is idiomatic JS and avoids accidental coercion surprises. The same style is used consistently everywhere else in both files (===). Small nit, but worth being consistent.


Silent failure when modifies_github_dir blocks a run

Both workflows refuse to run when the PR touches .github/ files, which is a sensible policy. However, unlike the fork_pr_refused case (which has a dedicated refuse-fork job that posts a comment explaining the policy), modifies_github_dir exits silently — no GitHub comment, no annotation, nothing. A user who posts @claude review on a PR that happens to touch .github/ will see... nothing, and have no idea why.

Consider adding a parallel refuse-github-dir job in claude-review.yml (similar to refuse-fork) that posts a brief explanation. Even a one-line "Claude automation is disabled for PRs that modify .github/ files" would be helpful.


Edge case: CLAUDE_APP_LOGIN unset in claude-review.yml

claude-review.yml lines 129–135:

} else if (
  context.eventName === 'issue_comment' &&
  trustedClaudeLogin &&        // <-- short-circuits if empty
  (pr.user?.login ?? '') === trustedClaudeLogin
) {
  reason = 'claude_pr_uses_write_workflow';
}

If CLAUDE_APP_LOGIN is not configured, trustedClaudeLogin is '', the && short-circuits, and claude-review.yml will run on issue comments on Claude-owned PRs instead of deferring to claude-write.yml. This is a misconfiguration, but the failure mode is silently wrong rather than loudly broken — the review workflow runs where the write workflow should. The claude-write.yml gate already handles the missing CLAUDE_APP_LOGIN case with missing_claude_app_login, so there's an asymmetry.

A log warning or explicit guard (e.g., if (!trustedClaudeLogin) { reason = 'missing_claude_app_login'; } early in the gate) might be worth adding so misconfiguration is surfaced immediately.


Design note: .github/ guard blocks review of this PR

The modifies_github_dir check applies to claude-review.yml as well, which means Claude cannot review any PR that modifies .github/ — including this one. That's presumably intentional (you wouldn't want the review workflow loading and executing workflow files from the PR being reviewed), but it's worth calling out explicitly in a comment in the YAML since it's a non-obvious consequence.


Minor: github.event.pull_request.number in concurrency group

claude-review.yml line 23:

group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}

For issue_comment events (which this workflow handles), github.event.pull_request is not present in the payload — so github.event.pull_request.number will be empty, and the fallback github.event.issue.number (which is the PR number for PR-linked issue comments) kicks in correctly. The logic is fine in practice, but github.event.pull_request.number being listed first when it never applies to issue_comment events is a bit misleading. A comment explaining the fallback would help future readers.


Nit: redundant Boolean() call

claude-write.yml line 104 / claude-review.yml line 123:

const isFork = !headRepo || Boolean(headRepo.fork) || headRepo.full_name !== ...

headRepo.fork is already a boolean from the GitHub API. Boolean(headRepo.fork) is a no-op but harmless.


What looks good

  • Pinning all actions to commit SHAs (not mutable tags) is excellent practice for supply chain security.
  • Separating the GitHub App private key into the claude-automation environment, which is only attached to the claude job (not the gate job), is the right design.
  • persist-credentials: false on both checkouts prevents credential leakage.
  • The bot_sender_refused check prevents automated loop amplification.
  • The checkout_ref is set to pr.head.sha (a fixed SHA) before the environment is attached, so even if the PR branch is updated between the gate and claude jobs, the checked-out code is deterministic.
  • allow-tools in claude-write.yml is appropriately scoped (cargo/uv only); claude-review.yml is further restricted to read-only git and search commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/chore A trivial change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants